---
title: 创建容器镜像
description: 编写正确、高效的 Dockerfile
---
当你想要打包一个 Docker 容器化应用，关键是要编写一个正确和高效的 Dockerfile。

## 镜像的内部机制
**镜像就是一个打包文件，里面包含了应用程序还有它运行所依赖的环境，例如文件系统、环境变量、配置参数等等。**

环境变量、配置参数这些东西是比较简单的，使用一个 manifest 清单就可以管理，真正麻烦的是文件系统。
为了保证容器运行环境的一致性，镜像必须把应用程序所在操作系统的根目录，也就是 rootfs，都包含进来。

虽然这些文件里不包含系统内核（因为容器共享了宿主机内核），但如果每个镜像都重复这样的打包操作，仍然会导致大量冗余，对磁盘存储、网络传输都是很大的浪费。

很自然的，我们就会想到，应该把重复的部分抽取出来，只存放一份根目录文件，然后让其余镜像以某种方式共享这部分数据。这种方式在容器镜像里叫做分层，术语叫  **Layer**

容器镜像内部并不是一个平坦的结构，而是由很多镜像层组成的，每层都是只读不可修改的一组文件，相同的层可以在镜像之间共享，然后多个层就像搭积木一样堆叠起来，在使用一种 **Union FS 联合文件系统** 的技术把它们联合在一起，就形成了容器最终看到的文件系统。

![img](https://img.wkq.pub/img/c750a7795ff4787c6639dd42bf0a473f.png)

可以使用 `docker inspect`来查看镜像的分层信息，比如 `nginx:alpine`
```bash
[root@docker ~]# docker inspect nginx:alpine
[
    {
        "Id": "sha256:cc44224bfe208a46fbc45471e8f9416f66b75d6307573e29634e7f42e27a9268",
        "RepoTags": [
            "nginx:1.21-alpine",
            "nginx:alpine"
        ],
        "RepoDigests": [
            "nginx@sha256:eb05700fe7baa6890b74278e39b66b2ed1326831f9ec3ed4bdc6361a4ac2f333"
        ],
        "Parent": "",
        "Comment": "",
        "Created": "2021-12-29T19:29:13.874392808Z",
        "Container": "7575fb322d4b00ae10e2adb9fa8ef3d9c855b8530ff2d4d0187a2734302b3469",
        "ContainerConfig": {
            "Hostname": "7575fb322d4b",
            "Domainname": "",
            "User": "",
            "AttachStdin": false,
            "AttachStdout": false,
            "AttachStderr": false,
            "ExposedPorts": {
                "80/tcp": {}
            },
            "Tty": false,
            "OpenStdin": false,
            "StdinOnce": false,
            "Env": [
                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
                "NGINX_VERSION=1.21.5",
                "NJS_VERSION=0.7.1",
                "PKG_RELEASE=1"
            ],
            "Cmd": [
                "/bin/sh",
                "-c",
                "#(nop) ",
                "CMD [\"nginx\" \"-g\" \"daemon off;\"]"
            ],
            "Image": "sha256:6818f894a012ecd044735f31c67f74ab749d254ca8385096062680514d3c3c5e",
            "Volumes": null,
            "WorkingDir": "",
            "Entrypoint": [
                "/docker-entrypoint.sh"
            ],
            "OnBuild": null,
            "Labels": {
                "maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
            },
            "StopSignal": "SIGQUIT"
        },
        "DockerVersion": "20.10.7",
        "Author": "",
        "Config": {
            "Hostname": "",
            "Domainname": "",
            "User": "",
            "AttachStdin": false,
            "AttachStdout": false,
            "AttachStderr": false,
            "ExposedPorts": {
                "80/tcp": {}
            },
            "Tty": false,
            "OpenStdin": false,
            "StdinOnce": false,
            "Env": [
                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
                "NGINX_VERSION=1.21.5",
                "NJS_VERSION=0.7.1",
                "PKG_RELEASE=1"
            ],
            "Cmd": [
                "nginx",
                "-g",
                "daemon off;"
            ],
            "Image": "sha256:6818f894a012ecd044735f31c67f74ab749d254ca8385096062680514d3c3c5e",
            "Volumes": null,
            "WorkingDir": "",
            "Entrypoint": [
                "/docker-entrypoint.sh"
            ],
            "OnBuild": null,
            "Labels": {
                "maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
            },
            "StopSignal": "SIGQUIT"
        },
        "Architecture": "amd64",
        "Os": "linux",
        "Size": 23456409,
        "VirtualSize": 23456409,
        "GraphDriver": {
            "Data": {
                "LowerDir": "/var/lib/docker/overlay2/b1d86441980daa9dc16730e9667178bb224d7c3e35252c41510548f4145fca07/diff:/var/lib/docker/overlay2/c396f930865ccd7451bfb4ef5c36f054ee3309cce0aae3739356f0030cb99e5a/diff:/var/lib/docker/overlay2/da22d5d48aeaaeea8f2d6706fe8666a962de31eaa8c925f5aed6eef6f09e5b3e/diff:/var/lib/docker/overlay2/25c11bfb28a958f0429df2e53cf4a90581e5fa387788cf834f979187db06e871/diff:/var/lib/docker/overlay2/18321d37d1be0bfaf05863300c27986edd19da81380dd7df56be16f012bcd38d/diff",
                "MergedDir": "/var/lib/docker/overlay2/ce291abe8b33bd4e2ab13a2d6c8798599dc3172e001b05834bc1b698fd7e8154/merged",
                "UpperDir": "/var/lib/docker/overlay2/ce291abe8b33bd4e2ab13a2d6c8798599dc3172e001b05834bc1b698fd7e8154/diff",
                "WorkDir": "/var/lib/docker/overlay2/ce291abe8b33bd4e2ab13a2d6c8798599dc3172e001b05834bc1b698fd7e8154/work"
            },
            "Name": "overlay2"
        },
        "RootFS": {
            "Type": "layers",
            "Layers": [
                "sha256:8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759",
                "sha256:1c9c1e42aafaa6067e6591a2b3214b4d6c6c357b597377bd395486d9999209cd",
                "sha256:3f87f0a06073927060d269480f2c55cd4ac978732291be1e900a0e834dbc0db0",
                "sha256:5ee3266a70bd586778d7d3dc36fd1dbc2ab407d559615a1c3508697eac86142f",
                "sha256:0e835d02c1b5a93d74af44edda6dc7b36938429847b13d0553a5ba4adab1e686",
                "sha256:419df8b600324530e8b64cabca7b0e6b959efc8d41fb2b8253014a4099747501"
            ]
        },
        "Metadata": {
            "LastTagTime": "0001-01-01T00:00:00Z"
        }
    }
]
```

它的分层信息在 `RootFS` 部分, `nginx:alpine` 镜像里一共有 6 个 Layer。

使用 `docker pull` 、 `docker rmi` 等命令操作镜像的时候，Docker 会检查是否有重复的层，如果本地已经存在就不会重复下载，如果层被其它镜像共享就不会删除，这样就可以节约磁盘和网络成本。

## `Dockerfile` 是什么

`Dockerfile`是一个纯文本，里面记录了一系列的构建指令，比如选择基础镜像、拷贝文件、运行脚本等等。每个指令都会生成一个 Layer，而 Docker 顺序执行这个文件里的所有步骤，最后就会创建出一个新的镜像出来。

```dockerfile title=一个简单的示例
# Dockerfile.busybox
FROM busybox   # 选择基础镜像
CMD echo "hello world"  #启动容器时默认运行的命令
```

这个文件里只有两条指令。

第一条指令 `FROM` ，所有的` Dockerfile ` 都要从它开始，表示选择构建使用的基础镜像。

第二条指令是 `CMD` ,它指定了 `docker run ` 启动容器时默认运行的命令。

```bash title='使用 docker build 创建镜像'
[root@docker ~]# docker build -f Dockerfile.busybox .
[+] Building 46.3s (5/5) FINISHED                                                                                                                                  docker:default
 => [internal] load build definition from Dockerfile.busybox                                                                                                                 0.0s
 => => transferring dockerfile: 165B                                                                                                                                         0.0s
 => [internal] load .dockerignore                                                                                                                                            0.0s
 => => transferring context: 2B                                                                                                                                              0.0s
 => [internal] load metadata for docker.io/library/busybox:latest                                                                                                           42.8s
 => [1/1] FROM docker.io/library/busybox@sha256:5acba83a746c7608ed544dc1533b87c737a0b0fb730301639a0179f9344b1678                                                             3.4s
 => => resolve docker.io/library/busybox@sha256:5acba83a746c7608ed544dc1533b87c737a0b0fb730301639a0179f9344b1678                                                             0.6s
 => => sha256:5acba83a746c7608ed544dc1533b87c737a0b0fb730301639a0179f9344b1678 2.29kB / 2.29kB                                                                               0.0s
 => => sha256:62ffc2ed7554e4c6d360bce40bbcf196573dd27c4ce080641a2c59867e732dee 527B / 527B                                                                                   0.0s
 => => sha256:beae173ccac6ad749f76713cf4440fe3d21d1043fe616dfbe30775815d1d0f6a 1.46kB / 1.46kB                                                                               0.0s
 => => sha256:5cc84ad355aaa64f46ea9c7bbcc319a9d808ab15088a27209c9e70ef86e5a2aa 772.79kB / 772.79kB                                                                           2.3s
 => => extracting sha256:5cc84ad355aaa64f46ea9c7bbcc319a9d808ab15088a27209c9e70ef86e5a2aa                                                                                    0.5s
 => exporting to image                                                                                                                                                       0.0s
 => => exporting layers                                                                                                                                                      0.0s
 => => writing image sha256:2f120bfa21f60d318d074ddf569a43cae335e11eed24b4da1c582f74b719af8e
```

使用 `-f` 参数指定 `Dockerfile` 文件名 ,后面必须跟一个文件路径，叫做 **构建上下文**（build's  context），这里使用 `.` 表示当前路径。

Dockers 会逐行读取并执行 `Dockerfile` 里的指令,依次创建镜像层，再生成完整的镜像。

新的镜像暂时还没有名字（用 `docker images`会看到的是 `<none>`）,但我们可以直接使用 `IMAGE ID`来查看和运行。

```bash
docker inspect b61
docker run b61
```

## 怎样编写正确、高效的 Dockerfile

首先构建镜像的第一条指令必须是 FROM，所以基础镜像的选择非常关键。如果关注的是镜像的安全和大小，那么一般会选择 Alpine；如果关注的是应用的运行稳定性，那么可能会选择 Ubuntu、Debian、CentOS.

```bash
FROM apline:3.15 # 选择Alpine 镜像
FROM ubuntu：bionic  # 选择 Ubuntu 镜像
```

在本机上开发测试时会产生一些源码、配置等文件，需要打包到镜像里，这时候可以使用 `COPY` 命令，它的用法和 Linux 的 `cp` 差不多,不过拷贝的源文件必须是 **构建上下文** 路径里的，不能随意指定文件。

```bash title='示例'
COPY ./a.txt /tmp/a.txt # 把构建上下文里的 a.txt 拷贝到镜像的 /tmp 目录
COPY /etc/hosts /tmp #错误！ 不能使用构建上下文之外的文件。
```

接下来要说的是 `Dockerfile` 里最重要的一个指令 `RUN`,它可以执行任意的 Shell 命令，比如更新系统、安装应用、下载文件、创建目录、编译程序等等，实现任意的镜像构建步骤。



`RUN` 通常会是 `Dockerfile` 里最复杂的指令 ,会包含很多的 Shell，但 `Dockerfile`里的一条指令只能是一行,所以有的 RUN 指令会在每行末尾使用续航符 `\` ,命令之间也会用 `&&` 来连接，这样保证逻辑上是一行，就像下面这样：

```bash
RUN apt-get update \
    && apt-get install -y \
        build-essential \
        curl \
        make \
        unzip \
    && cd /tmp \
    && curl -fSL xxx.tar.gz -o xxx.tar.gz\
    && tar xzf xxx.tar.gz \
    && cd xxx \
    && ./config \
    && make \
    && make clean
```

**也可以把这些 Shell 命令集中到一个脚本文件里，用 COPY 命令拷贝进去再用 RUN 来执行：**

```sh
COPY setup.sh  /tmp/                # 拷贝脚本到/tmp目录

RUN cd /tmp && chmod +x setup.sh \  # 添加执行权限
    && ./setup.sh && rm setup.sh    # 运行脚本然后再删除
```

RUN 指令实际上就是 Shell 编程，可以实现参数化运行，在 Dockerfile 里也可以做到，需要用到两个指令 `ARG` 和 `ENV`。

**它们的区别在于 `ARG` 创建的变量只在镜像构建 过程中可见，容器运行时不可见，而 `ENV `创建的变量不仅能够在构建镜像的过程中使用，在容器运行时也能够以环境变量的形式被应用程序使用。**

```bash title=简单示例
ARG IMAGE_BASE="node"
ARG IMAGE_TAG="alpine"

ENV PATH = $PATH:/tmp
ENV DEBUG=OFF
```

还有一个重要的指令是 `EXPOSE `, 它用来声明容器对外暴露的端口号。

```bash
EXPOSE 443
EXPOSE 54/udp
```

:::tip

因为每个指令都会生成一个镜像层，所以 Dockerfile里面最好不要滥用指令，尽量精简合并，否则太多的层会导致镜像臃肿不堪。

只有 RUN、COPY、ADD 会生成新的镜像层，其它指令只会产生临时层，不影响构建大小。

:::

## docker build 是如何工作的

`Dockerfile `必须要经过 docker build 才能生效。使用构建上下文是因为命令行 docker 是一个简单的客户端，真正的镜像构建工作是由服务器端的 `Docker daemon` 来完成的,所以 docker 客户端只能把 **构建 上下文**目录打包上传，这样服务器才能够获取本地的这些文件。

![img](https://img.wkq.pub/img/c8116066bdbf295a7c9fc25b87755dfe.jpg)

所以，构建上下文其实与 `Dockerfile` 没有直接的关系，它其实指定了要打包进镜像的一些依赖文件。而 **COPY** 命令也只能使用基于 **构建上下文** 的相对路径，因为 Docker daemon 看不到本地环境，只能看到打包上传后的那些文件。

但这个机制也有一些问题，如果目录里的一些文件不需要拷贝进镜像，docker 也会一股脑地打包上传，效率很低。

为了避免这种问题，可以在 **构建上下文**目录里再建立一个 `.dockerignore` 文件 ，语法与 `.gitignore`类似，排除那些不需要的文件。

下面是一个简单的示例，表示不打包上传后缀是 “swp” 、 “sh” 的文件：

```bash title=.dockerignore
*.swap
*.sh
```

另外关于 `Dockerfile`，一般应该在命令行里使用 `-f` 来显式指定。但是如果省略这个参数，docker build 就会在当前目录下查找名字是 `Dockerfile `的文件。

`-t` 参数，可以指定镜像的标签（tag）。这样 Docker 就会在构建完成后自动给镜像添加名字。

## 小结

**容器镜像是由多个只读的 Layer 构成的，同一个 Layer 可以被不同的镜像共享。**

编写 Dockerfle

1. 创建镜像需要编写 `Dokcerfile`，写清楚创建镜像的步骤，每个指令都会生成一个 Layer。
2. `Dockerfile` 里，第一个指令必须是 `FROM`,  用来选择基础镜像，常用的有 `Alpine`、`Ubuntu `等。其它常用的指令有  `COPY`、`RUN` 、`EXPOSE`，分别是拷贝文件，运行 Shell 命令，声明服务端口号。
3. docker build 需要使用 `-f` 指定 Dockerfile，如果不指定就使用当前目录下名字是 Dockerfile 的文件。
4. docker build 需要指定构建上下文，其中的文件会打包上传到 Docker daemon，所以尽量不要在构建上下文中存放多余的文件。
5. 创建镜像的时候应当尽量使用 `-t` 参数，为镜像起一个有意义的名字，方便管理。



```dockerfile
# Dockerfile
# docker build -t ngx-app .
# docker build -t ngx-app:1.0 .

ARG IMAGE_BASE="nginx"
ARG IMAGE_TAG="1.21-alpine"

FROM ${IMAGE_BASE}:${IMAGE_TAG}

COPY ./default.conf /etc/nginx/conf.d/

RUN cd /usr/share/nginx/html \
    && echo "hello nginx" > a.txt

EXPOSE 8081 8082 8083
```

```conf title=default.conf
server {
    listen 80;
    server_name localhost;

    location / {
        root /usr/share/nginx/html;
        index index.html;
    }
}
```

